iT邦幫忙

2024 iThome 鐵人賽

DAY 16
0
Software Development

DDD? Clean Architecture? Microservices? 帶你用.NET實作打造一個現代化微服務!系列 第 16

Day 16 - Account Infrastructure 實作:資料庫建置與 ORM 開發

  • 分享至 

  • xImage
  •  

前置作業

還記得我們先前在 Day 10 - 專案建置與 docker-compose 的時候使用 Docker Compose 來建置 Database 嗎?

我們把這個 docker 先跑起來,再來在 .\src 確定上述的 docker-compose.yml 存在後執行

docker compose up -d

可以在 Docker Desktop 看到我們的 Account Database

https://ithelp.ithome.com.tw/upload/images/20240929/20168953SCtGV5gmIo.png

然後我們用 Database Client 連線到 1433 port 的 SQL Server,執行下面的 Script:

-- 創建 db_account 資料庫
CREATE DATABASE db_account;

-- 切換到 db_account 資料庫
USE db_account;

-- 創建 tb_user 資料表
CREATE TABLE [dbo].[tb_user] (
    [id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
    [first_name] NVARCHAR(100) NOT NULL,
    [last_name] NVARCHAR(100) NOT NULL,
    [email] NVARCHAR(255) NOT NULL UNIQUE,
    [password_hash] NVARCHAR(255) NOT NULL, 
    [created_date_time] DATETIME2 NOT NULL,
    [updated_date_time] DATETIME2 NOT NULL,
    CONSTRAINT UQ_User_Email UNIQUE (email)
);

重新整理一下,就可以看到我們的資料表了。

https://ithelp.ithome.com.tw/upload/images/20240929/20168953SY7cnAxAGO.png

對截圖工具有興趣,可以到 Day 09 - 開發工具 找我在這裡用的所有 Extensions。


安裝 Entity Framework (EF)

有了資料庫,我們要來選擇 ORM(Object-Relational Mapping),為了控制篇幅,我們這邊選擇 Entity Framework。

首先在 Infrastructure Layer 安裝 Microsoft.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.SqlServer

建立 DbContext

DbContext 是 EF Core 用來管理應用程式與資料庫之間資料存取的類別,負責查詢、儲存和管理資料的變更追蹤。

using Account.Domain.Aggregates;
using Microsoft.EntityFrameworkCore;

namespace Account.Infrastructure;

public class AccountContext : DbContext
{
    public DbSet<User> Users { get; set; }

    public AccountContext(DbContextOptions<AccountContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.ApplyConfiguration(new AccountConfiguration());
    }
}

建立 EntityTypeConfiguration

有了 DbContext,接著就要來對應用程式與資料庫之間資料的差異進行一些詳細的配置。

using Account.Domain.Aggregates;
using Account.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Account.Infrastructure;

public class AccountConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        // 設定資料表名稱
        builder.ToTable("tb_user");

        // 設定主鍵
        builder.HasKey(u => u.Id);

        // 設定 Property Mapping
        // 轉換 UserId -> Guid
        // 需要建立新的 Overloading Method: UserId.Create(Value)
        builder.Property(u => u.Id)
               .HasColumnName("id")
               .ValueGeneratedNever()
               .HasConversion(
                   id => id.Value,
                   value => UserId.Create(value))
               .IsRequired();

        // 設定 Property Mapping
        builder.Property(u => u.FirstName)
               .HasColumnName("first_name")
               .HasMaxLength(100)
               .IsRequired();

        // 設定 Property Mapping
        builder.Property(u => u.LastName)
               .HasColumnName("last_name")
               .HasMaxLength(100)
               .IsRequired();

        // 設定 Property Mapping
        builder.Property(u => u.Email)
               .HasColumnName("email")
               .HasMaxLength(255)
               .IsRequired();
        // Email 有 UNIQUE CONSTRAINT 
        builder.HasIndex(u => u.Email)
               .IsUnique();

        // 設定 Property Mapping
        // 這邊需要實作將 Password 加密成 PasswordHash
        builder.Property(u => u.PasswordHash)
               .HasColumnName("password_hash")
               .HasMaxLength(255)
               .IsRequired();

        // 設定 Property Mapping
        builder.Property(u => u.CreatedDateTime)
               .HasColumnName("created_date_time")
               .IsRequired();

        // 設定 Property Mapping
        builder.Property(u => u.UpdatedDateTime)
               .HasColumnName("updated_date_time")
               .IsRequired();
    }
}

多載 UserId.Create()

我們為了要讓 EF 可以在從資料庫取回 ID 值時 Create 一個 UserId,我們得在 Account.Domain 內的 UserId.cs 加入這個 Overloading Method

    public static UserId Create(Guid value)
    {
        return new(value);
    }

Password 加密

我們要把 Password 在整個專案跟 Database 內都變成加密的 PasswordHash

修改一下在 Account.Domain 內的 User.cs,新增一個加密的方法 HashPassword,然後把 Password 改成 PasswordHash,整體如下:

using System.Security.Cryptography;
using System.Text;
using Account.Domain.ValueObjects;
using Common.Library.Seedwork;

namespace Account.Domain.Aggregates;

public class User : Entity<UserId>, IAggregateRoot
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string Email { get; private set; }
    public string PasswordHash { get; private set; }

    private User() { }

    public User(UserId id, string firstName, string lastName, string email, string password)
    {
        Id = id;
        FirstName = firstName;
        LastName = lastName;
        Email = email;
        PasswordHash = HashPassword(password); // Hash
        CreatedDateTime = DateTime.UtcNow;
        UpdatedDateTime = DateTime.UtcNow;
    }

    public static User Create(
        string firstName,
        string lastName,
        string email,
        string password)
        => new()
        {
            Id = UserId.Create(),
            FirstName = firstName,
            LastName = lastName,
            Email = email,
            PasswordHash = HashPassword(password), // Hash
            CreatedDateTime = DateTime.UtcNow,
            UpdatedDateTime = DateTime.UtcNow
        };

    public bool VerifyPassword(string password) => PasswordHash == HashPassword(password); // Hash

    private static string HashPassword(string password)
    {
        var hashedBytes = SHA256.HashData(Encoding.UTF8.GetBytes(password));
        return Convert.ToHexString(hashedBytes);
    }
}

設定連線字串

接著來處理連線字串的配置問題,回顧一下 Day 10 的連線字串,並把 Database 從 master 改成剛剛創建的 db_account,記得 SSL 的認證也要關掉。

Server=localhost,1433;Database=db_account;User Id=sa;Password=Passw0rd!TrustServerCertificate=True;

AccountContext.cs override OnConfiguring 並 DI IConfiguration

using Account.Domain.Aggregates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace Account.Infrastructure;

public class AccountContext : DbContext
{   
    private const string DEFAULT_CONNECTION_SECTION = "DefaultConnection";
    private readonly IConfiguration _configuration;

    public DbSet<User> Users { get; set; }

    public AccountContext(DbContextOptions<AccountContext> options, IConfiguration configuration) : base(options)
    {
        _configuration = configuration;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseSqlServer(_configuration.GetConnectionString(DEFAULT_CONNECTION_SECTION));
        }
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.ApplyConfiguration(new AccountConfiguration());
    }
}

最後我們將連線字串放進我們的 appsettings 裡面

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http2"
    }
  },
  "JwtSettings": {
    "Secret": "my-secret",
    "ExpiryInMinutes": 5,
    "Issuer": "todo-issuer",
    "Audience": "todo-audience"
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost,1433;Database=db_account;User Id=sa;Password=Passw0rd!;TrustServerCertificate=True"
  }
}

注入 DbContext

我們修改一下 AccountInfrastructureRegister.cs

using Account.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Account.Infrastructure;

public static class AccountInfrastructureRegister
{
    public static IServiceCollection AddAccountInfrastructure(this IServiceCollection services)
    {
        services.AddScoped<IUserRepository, UserRepository>();
        services.AddSingleton<ITokenProvider, JwtProvider>();
        services.AddDbContext<AccountContext>();

        return services;
    }
}

修改 Repository

上面把跟 SQL Server 的連線配置都做好了,現在要來修改 UserRepository 讓原本存在記憶體的 _users 改成存在 Database。

但在這之前,我們 Repository 因為 Aggregate Root 全成功與全失敗的特性,我們有讓它具備 IUnitOfWork 的屬性,而在 EF Core 的 DbContext 本身就是一個完整的管理功能,可以當成我們的 UnitOfWork 實體,故讓它繼承 IUnitOfWork,修改如下:

using Account.Domain.Aggregates;
using Common.Library.Seedwork;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace Account.Infrastructure;

public class AccountContext : DbContext, IUnitOfWork
{
    private const string DEFAULT_CONNECTION_SECTION = "DefaultConnection";
    private readonly IConfiguration _configuration;

    public DbSet<User> Users { get; set; }

    public AccountContext(DbContextOptions<AccountContext> options, IConfiguration configuration) : base(options)
    {
        _configuration = configuration;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseSqlServer(_configuration.GetConnectionString(DEFAULT_CONNECTION_SECTION));
        }
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.ApplyConfiguration(new AccountConfiguration());
    }

    public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            await base.SaveChangesAsync(cancellationToken);
            return true; 
        }
        catch (Exception)
        {
            return false;
        }
    }
}

接著修改 UserRepository,並讓 AccountContext 當作 UnitOfWork 實體來使用。

using Account.Application;
using Common.Library.Seedwork;

namespace Account.Infrastructure;

public class UserRepository : IUserRepository
{
    private readonly AccountContext _accountContext;

    public UserRepository(AccountContext accountContext)
    {
        this._accountContext = accountContext ?? throw new ArgumentNullException(nameof(accountContext));
    }

    public IUnitOfWork UnitOfWork => _accountContext;

    public Domain.Aggregates.User? GetUserByEmail(string email)
    {
        return _accountContext.Users.SingleOrDefault(u => u.Email == email);
    }

    public void Add(Domain.Aggregates.User user)
    {
        _accountContext.Users.Add(user);
    }
}

最後在每個 reference 用 IUnitOfWork 確認 Transaction。在這裡就是 AccountServiceRegister

    public AuthenticationResult Register(string firstName, string lastName, string email, string password)
    {
        if (_userRepository.GetUserByEmail(email) is not null)
            throw new ArgumentException("Email address already exists");

        var user = User.Create(firstName, lastName, email, password);

        _userRepository.Add(user);

        _userRepository.UnitOfWork.SaveEntitiesAsync().Wait();

        return new AuthenticationResult(
            user.Id.Value,
            user.FirstName,
            user.LastName,
            user.Email,
            _tokenProvider.GenerateToken(user.Id.Value, user.FirstName, user.LastName)
        );
    }

測試

最後來測試一下,記得先把 gRPC Clicker 的 Timeout 拉長

https://ithelp.ithome.com.tw/upload/images/20240929/201689530ZydLYWvKW.png

註冊帳號,並檢查 Database,這裡可以注意一下 Hash Password 有沒有成功

https://ithelp.ithome.com.tw/upload/images/20240929/20168953L1Flpp8pek.png

登入看看

https://ithelp.ithome.com.tw/upload/images/20240929/20168953Q07FqDVv2H.png

驗證看看 Token 是否正確

https://ithelp.ithome.com.tw/upload/images/20240929/20168953xmKwJ3H2VV.png

結語

到目前為止,我們完成了大部份的 Account Service 的功能,接著先去實作完 Todo Service,再來完成 Event 的功能。

最後補上架構圖

https://ithelp.ithome.com.tw/upload/images/20240929/20168953pxEmxruMQa.png


上一篇
Day 15 - Account Service 實作:Authentication JWT 製作與認證
下一篇
Day 17 - Todo Service 實作:一次完成 Create List 和 Create Item
系列文
DDD? Clean Architecture? Microservices? 帶你用.NET實作打造一個現代化微服務!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言